Skip to content

Latest commit

 

History

History
238 lines (178 loc) · 7.32 KB

File metadata and controls

238 lines (178 loc) · 7.32 KB

Unit Testing Patterns in Swift

Table Of Contents

  1. How To Handle XCTAssert* in Helper Methods?
  2. How To Detect Memory Leaks in Unit Tests?
  3. How To Test That Function Throws An Error?
    1. Using do-catch
    2. Using XCTAssertThrowsError
  4. How To Test That Function Does Not Throw An Error?
    1. Using do-catch
    2. Using throws in the test method
    3. Using XCTAssertNoThrowError
  5. How To Test Optional Values?
  6. How To Check That a Callback is Not Called?
  7. How To Test Asynchronous Callbacks?
  8. How To Test viewDidLoad method from UIViewController?
  9. References

How To Handle XCTAssert* in Helper Methods?

When calling the XCTAssert* functions outside the test method (e.g., in helper methods), it's crucial to provide the #filePath and #line parameters. This allows Xcode to precisely identify the specific test that failed and its location (the actual tested method, not a helper method).

func test_method() {
    // ... Arrange & Act
    
    // Assert: helper method
    helperExpect(param) // It should fail here ✅
}

private func helperExpect(param: Bool, file: StaticString = #filePath, line: UInt = #line) {
    //...
    XCTAssertTrue(param: Bool, file: file, line: line) // Not here ❌
}

How To Detect Memory Leaks in Unit Tests?

If you're testing the collaboration between two classes in a parent-child relationship (for example, where the parent is the SUT and the child is a Spy), there's a risk of having a retain cycle, especially when asynchronous functions with closures are involved. To verify the presence of a retain cycle and potential memory leaks, you can follow these steps:

// A factory helper for SUT creation
private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> SomeSutType {
    // Instantiate the Spy
    let spy = ...
    
    // Instantiate the SUT 
    let sut = ...
    
    assertForMemoryLeakOnTeardown(spy, file: file, line: line) // ✅  Check the Spy instance for memory leaks
    assertForMemoryLeakOnTeardown(sut, file: file, line: line) // ✅  Check the SUT instance for memory leaks
        
    return sut
}

private func assertForMemoryLeakOnTeardown(_ object: AnyObject, file: StaticString = #filePath, line: UInt = #line) {
    // ✅  This block runs assertion when a test is finished
    addTeardownBlock { [weak object] in
        XCAssertNil(object, "The object instance has not been deallocated.", file: file, line: line)
    }
}

How To Test That Function Throws An Error?

Using do-catch

This method adds branching logic (do-catch) to the test which is not desired.

func test_validate_whenPhoneNumberIsInvalid_shouldThrowException() {
    let sut = PhoneNumberValidator()
    let INVALID_NUMBER = "g122345j"
    
    do {
        try sut.validate(INVALID_NUMBER)
        
        XCTFail("The validate() was supposed to throw an error.")
    } catch PhoneNumberValidator.Error {
        // Successfully passing
        return
    } catch {
        XCTFail("The validate() was supposed to throw `PhoneNumberValidator.Error` when phone number is invalid. A different error was thrown.")
    }
}

Using XCTAssertThrowsError

You can also avoid do-catch by using XCTAssertThrowsError.

func test_validate_whenPhoneNumberIsInvalid_shouldThrowException() {
    let sut = PhoneNumberValidator()
    let INVALID_NUMBER = "g122345j"
    
    let act = {
        try sut.validate(INVALID_NUMBER)
    }
    
    XCTAssertThrowsError(try act(), "Invalid number error should be thrown") { error in // ✅
        XCTAssertEqual(error as? PhoneNumberValidator.Error, .invalidNumber)
    }
}

How To Test That Function Does Not Throw An Error?

Using do-catch

func test_validate_whenPhoneNumberIsValid_shouldNotThrowException() {
    let sut = PhoneNumberValidator()
    let VALID_NUMBER = "777-777-777"
    
    do {
        let result = try sut.validate(VALID_NUMBER)
        XCTAssertTrue(result)
    } catch {
        XCTFail("The validate() was not supposed to throw an error.")
    }
}

Using throws in the test method

Mark XCTestCase methods as throwable to avoid do-catch in your test code.

func test_validate_whenPhoneNumberIsValid_shouldNothrowException() {
    let sut = PhoneNumberValidator()
    let VALID_NUMBER = "777-777-777"
    let result = try sut.validate(VALID_NUMBER)
    XCTAssertTrue(result)
}

Using XCTAssertNoThrow

func test_validate_whenPhoneNumberIsValid_shouldNotThrowException() {
    let sut = PhoneNumberValidator()
    let VALID_NUMBER = "777-777-777"
    
    XCTAssertNoThrow(try sut.validate(VALID_NUMBER), "The validate() should not throw an error when the phone number is valid")
}

How To Test Optional Values?

Use try XCTUnwrap() to test optional values

func testOptional() throws {
    let optionalValue: Int? = 11
    XCTAssertNotNil(unwrappedValue, optionalValue!) // ❌
}

func testOptional() throws {
    let optionalValue: Int? = 11
    let unwrappedValue = try XCTUnwrap(optionalValue) // ✅
    XCTAssertEqual(unwrappedValue, 11)
}

func test_viewDidLoad_loadsConsentScript() throws {
    // Given `SomeViewController()` which might return `nil`
    let sut = try XCTUnwrap(SomeViewController())

    // When
    sut.loadViewIfNeeded()

    /* ... */
}

How To Check That a Callback is Not Called?

Use expectation.isInverted for to check that a callback is not called:

func test_observeQueueBecomingEmpty_whenDequeuedCalledAndQueueIsStillNotEmpty_shouldNotCallObservingHandler() {
    let sut = QueueService(queue: ["George", "Sam", "Steven"])

    let expectation = expectation(description: "Handler for the queue becoming empty")
    expectation.isInverted = true // ✅

    sut.observeQueueBecomingEmpty {
        XCTFail("The observation handler for the queue becoming empty should not be triggered")
        expectation.fulfill()
    }
    
    sut.dequeue()

    waitForExpectations(timeout: 0.1)
}

How To Test Asynchronous Callbacks?

func test_fetch_shouldGetBooks() {
    let sut = BookRepository()
    
    // Create an expectation ✅
    let expectation = expectation(description: "Loading books") 

    sut.fetch {
        // Mark the expectation as fulfilled ✅
        expectation.fulfill()
    }

    // Wait for all expectations to be fulfilled ✅
    waitForExpectations(timeout: 1)

    XCTAssertFalse(sut.books.isEmpty)
}

How To Test viewDidLoad method from UIViewController?

Do not call the viewDidLoad method directly, as it might be called multiple times. Instead, call loadViewIfNeeded to invoke viewDidLoad indirectly.

func test_viewDidLoad() {
    // Given
    let sut = SomeViewController()

    // When
    sut.loadViewIfNeeded()

    // Then
    /* ... */
}

References